package de.schweigerconsulting.util.csv;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.List;

/**
 * Copyright 2011 Stefan Schweiger
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License. 
 * 
 * The CsvParser class provides the mechanism to parse a CSV file based on the
 * RFC4180 (http://tools.ietf.org/html/rfc4180).
 * 
 * @author Stefan Schweiger
 * @version 0.1.0 (Date: 12/15/2009)
 * 
 */
public class CsvParser {
	/**
	 * The given Reader Object to read the information out of a CSV file.
	 */
	private Reader reader;

	/**
	 * The <code>char</code> array <code>data</code> contains the data of the
	 * file reader stream after the private <code>readFile()</code> method was
	 * processed at the constructor.
	 */
	private char[] data;

	/**
	 * This is the field separator. It defines which character enclosed the
	 * valid data of a field.
	 */
	private char fieldSeparator;

	/**
	 * The quote char defines which character is used to parse escaped field
	 * data.
	 */
	private char quoteChar;

	/**
	 * The index represents the current position of the parsing mechanism.
	 */
	private int index;

	/**
	 * This boolean defines if the CSV data contains a headline or not.
	 */
	private boolean headline;

	/**
	 * Class constructor specifying all required parameters.
	 * 
	 * @param reader
	 *            The Reader which provides the data stream.
	 * @param fieldSeparator
	 *            Defines the character which separates the several fields.
	 * @param quoteChar
	 *            Defines the quote character.
	 * @param headline
	 *            Specifies if a headline is available.
	 * @throws IOException
	 */
	protected CsvParser(Reader reader, char fieldSeparator, char quoteChar,
			boolean headline) throws IOException {
		super();
		this.reader = reader;
		this.fieldSeparator = fieldSeparator;
		this.quoteChar = quoteChar;
		this.headline = headline;

		this.index = 0;
		readFile();
	}

	/**
	 * The parse() method is parsing the given file stream and transforms it
	 * into a List of CsvRecord objects.
	 * 
	 * @return A List<CsvRecord> containing all CSV records found in the given
	 *         file.
	 * @throws IOException
	 * @see CsvRecord
	 */
	protected List<CsvRecord> parse() throws IOException {
		/*
		 * Define a List which holds the data for each CSV record.
		 */
		List<CsvRecord> result = new ArrayList<CsvRecord>();

		/*
		 * Define CsvHeadline 'csvHeadline' to enclose the headline information
		 * to all CsvRecords.
		 */
		CsvHeadline csvHeadline = new CsvHeadline();

		/*
		 * Define StringBuilder 'field' to build a String that contains the data
		 * of the current quoted field.
		 */
		StringBuilder field = new StringBuilder();

		/*
		 * This List of Strings contains the data of each CsvRecord. It will be
		 * reseted after each line break.
		 */
		List<String> record = new ArrayList<String>();

		/*
		 * Parsing the char array 'this.data' character by character. The index
		 * has been initialized already at the constructor.
		 */
		for (; this.index < this.data.length; this.index++) {
			/*
			 * Current character.
			 */
			char c = this.data[this.index];

			/*
			 * If the current character is a quote char the parseQuoteField() is
			 * called. The returned data will be add to the List<String>
			 * 'record'.
			 */
			if (this.isQuoteChar(c)) {
				record.add(parseQuoteField());

				/*
				 * If the current character (after calling parseQuoteField) is a
				 * line break, the record will transformed to a CsvRecord
				 * object. This only happens if the quote field is the last
				 * field of a record (a line in the CSV file).
				 */
				if (isLineBreak(this.data[this.index])) {
					/*
					 * If the headline flag was defined 'true' and the
					 * csvHeadline does not contains any data, the record data
					 * will be added to the CsvHeadline object. This happens if
					 * the quote field is the last field of the first line (the
					 * headline) of the CSV file.
					 */
					if (this.headline && csvHeadline.getData() == null) {
						csvHeadline.setData(record.toArray(new String[0]));
					}
					/*
					 * If the headline data was already initialized, the data
					 * will be transformed to a CsvRecord. Additionally the
					 * csvHeadline will be connected to the new CsvRecord.
					 */
					else if (this.headline && csvHeadline.getData() != null) {
						result.add(new CsvRecord(record.toArray(new String[0]),
								csvHeadline));
					}
					/*
					 * If the quote field was just the last item of a record.
					 */
					else {
						result
								.add(new CsvRecord(record
										.toArray(new String[0])));
					}

					/*
					 * Reset the record.
					 */
					record.removeAll(record);
				}
			}
			/*
			 * If the current character is a line break ('\n') the record and
			 * the field will be reseted after appending the data to the result
			 * list.
			 */
			else if (this.isLineBreak(c)) {
				/*
				 * Add field to record.
				 */
				record.add(field.toString());

				/*
				 * Reset the field.
				 */
				field = new StringBuilder();

				/*
				 * See above.
				 */
				if (this.headline && csvHeadline.getData() == null) {
					csvHeadline.setData(record.toArray(new String[0]));
				}
				/*
				 * See above.
				 */
				else if (this.headline && csvHeadline.getData() != null) {
					result.add(new CsvRecord(record.toArray(new String[0]),
							csvHeadline));
				}
				/*
				 * See above.
				 */
				else {
					result.add(new CsvRecord(record.toArray(new String[0])));
				}

				/*
				 * Reset the record.
				 */
				record.removeAll(record);
			}
			/*
			 * If the current character is a field separator the field will be
			 * added to the current record list. Afterwards the field will be
			 * reseted.
			 */
			else if (this.isFieldSeparator(c)) {
				/*
				 * Add field to record.
				 */
				record.add(field.toString());

				/*
				 * Reset the field.
				 */
				field = new StringBuilder();
			}
			/*
			 * Else means that the current character represents no control
			 * functionality. The character is appended to current field.
			 */
			else {
				/*
				 * Append character to the StringBuilder 'field'.
				 */
				field.append(c);
			}
		}

		/*
		 * After parsing all characters, the last field has to be added to the
		 * current last record. This will be done if the size of the is greater
		 * than 0 (file is not empty) and the last read character was not a
		 * quote char (already added before).
		 */
		if (this.data.length > 0
				&& !this.isQuoteChar(this.data[this.index - 1])) {
			record.add(field.toString());
		}

		/*
		 * See above.
		 */
		if (this.headline && csvHeadline.getData() == null) {
			csvHeadline.setData(record.toArray(new String[0]));
		}
		/*
		 * See above.
		 */
		else if (this.headline && csvHeadline.getData() != null) {
			result
					.add(new CsvRecord(record.toArray(new String[0]),
							csvHeadline));
		}
		/*
		 * See above.
		 */
		else {
			/*
			 * Only add a CsvRecord to the result list, if the size of the char
			 * array is greater than 0 (file not empty).
			 */
			if (this.data.length > 0) {
				result.add(new CsvRecord(record.toArray(new String[0])));
			}
		}

		/*
		 * Return the list of CsvResult objects.
		 */
		return result;
	}

	/**
	 * This method is called if a quoted field is detected at the parse()
	 * method. A quoted field will be parsed and the data will be returned.
	 * 
	 * @return A String containing data of an quoted field.
	 */
	private String parseQuoteField() {
		/*
		 * Define StringBuilder 'field' to build a String that contains the data
		 * of the current quoted field.
		 */
		StringBuilder field = new StringBuilder();

		/*
		 * Parsing the char array 'this.data' character by character. By
		 * incrementing the current index position, the introductory quote char
		 * will be ignored.
		 */
		for (this.index++; this.index < this.data.length; this.index++) {
			/*
			 * Current character.
			 */
			char c = this.data[this.index];

			/*
			 * If the current character is a quote char and the next character
			 * is a quote char too, the quote char will be appended to the
			 * StringBuilder. By incrementing the index position, the escaping
			 * quote char will be ignored.
			 */
			if (isQuoteChar(c) && hasNext()
					&& isQuoteChar(this.data[this.index + 1])) {
				field.append(c);
				this.index++;
			}
			/*
			 * If the current character is a quote char and the next character
			 * is not a quote char or a line break, the index will be
			 * incremented and the parsing mechanism for the current quoted
			 * field will be left.
			 * 
			 * This means, that the quoting field data has ended.
			 */
			else if (isQuoteChar(c) && hasNext()
					&& !isQuoteChar(this.data[this.index + 1])) {
				this.index++;
				break;
			}
			/*
			 * If the end of data was reached, the parsing mechanism will be
			 * halted.
			 */
			else if (!hasNext()) {
				break;
			}
			/*
			 * Else means, that there is valid data to append to the
			 * StringBuilder.
			 */
			else {
				field.append(c);
			}
		}

		/*
		 * Return the parsed field data.
		 */
		return field.toString();
	}

	/**
	 * This method reads the data from the file reader stream and stores it into
	 * the char array 'data'.
	 * 
	 * @throws IOException
	 */
	private void readFile() throws IOException {
		/*
		 * Define StringBuilder 'str' to build a String that contains the data
		 * from the file reader stream.
		 */
		StringBuilder str = new StringBuilder();
		BufferedReader fin = new BufferedReader(this.reader);

		/*
		 * Read line by line from the buffered reader stream and append each
		 * line with an enclosing line break ('\n') to the StringBuilder 'str'.
		 */
		String line;
		while ((line = fin.readLine()) != null) {
			str.append(line);
			str.append('\n');
		}

		/*
		 * Close the buffered reader stream.
		 */
		fin.close();

		/*
		 * Store data into char array 'this.data'. By using the substring()
		 * method, the last appended line break will be removed. If the
		 * StringBuffer contains no data, the char array 'this.data' will be
		 * initialized with a zero-sized char array
		 */
		if (str.toString().length() > 0) {
			this.data = str.toString()
					.substring(0, str.toString().length() - 1).toCharArray();
		} else {
			this.data = new char[0];
		}
	}

	/**
	 * This method tests, if the given character equals the defined field
	 * separator.
	 * 
	 * @param c
	 *            Character to check if it equals the defined field separator or
	 *            not.
	 * @return <code>true</code> if the given character equals the defined field
	 *         separator, <code>false</code> if not.
	 */
	private boolean isFieldSeparator(char c) {
		return (c == this.fieldSeparator);
	}

	/**
	 * This method tests, if the given character equals the defined quote char.
	 * 
	 * @param c
	 *            Character to check if it equals the defined quote char or not.
	 * @return <code>true</code> if the given character equals the defined quote
	 *         char, <code>false</code> if not.
	 */
	private boolean isQuoteChar(char c) {
		return (c == this.quoteChar);
	}

	/**
	 * This method tests, if the given character is a line break (equals '\n').
	 * 
	 * @param c
	 *            Character to check if it is a line break ('\n') or not.
	 * @return <code>true</code> if the given character is a line break ('\n'),
	 *         <code>false</code> if not.
	 */
	private boolean isLineBreak(char c) {
		return c == '\n';
	}

	/**
	 * This method tests, if there exists a further character or the end of data
	 * is reached.
	 * 
	 * @return <code>true</code> if the date has a further character,
	 *         <code>false</code> if the next character is null (end of data).
	 */
	private boolean hasNext() {
		return (this.data.length > this.index + 1);
	}
}